#!/usr/bin/env python
# Copyright 2009-2022 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2

import os
import signal

# For compatibility with Python < 3.8
raise_signal = getattr(
    signal, "raise_signal", lambda signum: os.kill(os.getpid(), signum)
)


# Inherit from KeyboardInterrupt to avoid a traceback from asyncio.
class SignalInterrupt(KeyboardInterrupt):
    def __init__(self, signum):
        self.signum = signum


try:

    def signal_interrupt(signum, _frame):
        raise SignalInterrupt(signum)

    def debug_signal(_signum, _frame):
        import pdb

        pdb.set_trace()

    # Prevent "[Errno 32] Broken pipe" exceptions when writing to a pipe.
    signal.signal(signal.SIGPIPE, signal.SIG_DFL)
    signal.signal(signal.SIGTERM, signal_interrupt)
    signal.signal(signal.SIGUSR1, debug_signal)

    import argparse
    import stat
    import sys
    import functools
    import logging
    import subprocess
    import time
    import textwrap
    import re

    from os import path as osp

    if osp.isfile(
        osp.join(
            osp.dirname(osp.dirname(osp.realpath(__file__))), ".portage_not_installed"
        )
    ):
        sys.path.insert(
            0, osp.join(osp.dirname(osp.dirname(osp.realpath(__file__))), "lib")
        )
    import portage

    portage._internal_caller = True
    from portage import os, _encodings, _unicode_encode, _unicode_decode
    from portage.cache.cache_errors import CacheError, StatCollision
    from portage.cache.index.pkg_desc_index import (
        pkg_desc_index_line_format,
        pkg_desc_index_line_read,
    )
    from portage.const import TIMESTAMP_FORMAT
    from portage.dep import _repo_separator
    from portage.output import colorize, EOutput
    from portage.package.ebuild._parallel_manifest.ManifestScheduler import (
        ManifestScheduler,
    )
    from portage.util import cmp_sort_key, writemsg_level, no_color
    from portage.util._async.AsyncFunction import AsyncFunction
    from portage.util._async.run_main_scheduler import run_main_scheduler
    from portage.util._async.TaskScheduler import TaskScheduler
    from portage.util._eventloop.global_event_loop import global_event_loop
    from portage.util.changelog import ChangeLogTypeSort
    from portage import cpv_getkey
    from portage.dep import Atom, isjustname
    from portage.versions import vercmp
    from _emerge.MetadataRegen import MetadataRegen

    try:
        from xml.etree import ElementTree
    except ImportError:
        pass
    else:
        try:
            from xml.parsers.expat import ExpatError
        except ImportError:
            pass
        else:
            from portage.xml.metadata import (  # pylint: disable=ungrouped-imports
                parse_metadata_use,
            )

    def parse_args(args):
        usage = "egencache [options] <action> ... [atom] ..."
        parser = argparse.ArgumentParser(usage=usage)

        actions = parser.add_argument_group("Actions")
        actions.add_argument(
            "--update",
            action="store_true",
            help="update metadata/md5-cache/ (generate as necessary)",
        )
        actions.add_argument(
            "--update-use-local-desc",
            action="store_true",
            help="update the use.local.desc file from metadata.xml",
        )
        actions.add_argument(
            "--update-changelogs",
            action="store_true",
            help="update the ChangeLog files from SCM logs",
        )
        actions.add_argument(
            "--update-pkg-desc-index",
            action="store_true",
            help="update package description index",
        )
        actions.add_argument(
            "--update-manifests", action="store_true", help="update manifests"
        )

        common = parser.add_argument_group("Common options")
        common.add_argument("--repo", action="store", help="name of repo to operate on")
        common.add_argument(
            "--config-root",
            help="location of portage config files",
            dest="portage_configroot",
        )
        common.add_argument(
            "--external-cache-only",
            action="store_true",
            help="Output only to the external cache (not the repository itself)",
        )
        common.add_argument(
            "--gpg-dir", help="override the PORTAGE_GPG_DIR variable", dest="gpg_dir"
        )
        common.add_argument(
            "--gpg-key", help="override the PORTAGE_GPG_KEY variable", dest="gpg_key"
        )
        common.add_argument(
            "--repositories-configuration",
            help="override configuration of repositories (in format of repos.conf)",
            dest="repositories_configuration",
        )
        common.add_argument(
            "--sign-manifests",
            choices=("y", "n"),
            metavar="<y|n>",
            help="manually override layout.conf sign-manifests setting",
        )
        common.add_argument(
            "--strict-manifests",
            choices=("y", "n"),
            metavar="<y|n>",
            help='manually override "strict" FEATURES setting',
        )
        common.add_argument(
            "--thin-manifests",
            choices=("y", "n"),
            metavar="<y|n>",
            help="manually override layout.conf thin-manifests setting",
        )
        common.add_argument(
            "--tolerant",
            action="store_true",
            help="exit successfully if only minor errors occurred",
        )
        common.add_argument(
            "--ignore-default-opts",
            action="store_true",
            help="do not use the EGENCACHE_DEFAULT_OPTS environment variable",
        )
        common.add_argument(
            "-v", "--verbose", action="count", default=0, help="increase verbosity"
        )
        common.add_argument(
            "--write-timestamp",
            action="store_true",
            help="write metadata/timestamp.chk as required for rsync repositories",
        )

        update = parser.add_argument_group("--update options")
        update.add_argument(
            "--cache-dir", help="location of the metadata cache", dest="cache_dir"
        )
        update.add_argument(
            "-j",
            "--jobs",
            type=int,
            action="store",
            help="max ebuild processes to spawn",
        )
        update.add_argument(
            "--load-average",
            type=float,
            action="store",
            help="max load allowed when spawning multiple jobs",
            dest="load_average",
        )
        update.add_argument(
            "--rsync",
            action="store_true",
            help="enable rsync stat collision workaround "
            + "for bug 139134 (use with --update)",
        )

        uld = parser.add_argument_group("--update-use-local-desc options")
        uld.add_argument(
            "--preserve-comments",
            action="store_true",
            help="preserve the comments from the existing use.local.desc file",
        )
        uld.add_argument(
            "--use-local-desc-output",
            help="output file for use.local.desc data (or '-' for stdout)",
            dest="uld_output",
        )

        uc = parser.add_argument_group("--update-changelogs options")
        uc.add_argument(
            "--changelog-reversed",
            action="store_true",
            help="log commits in reverse order (oldest first)",
        )
        uc.add_argument(
            "--changelog-output",
            help="output filename for change logs",
            dest="changelog_output",
            default="ChangeLog",
        )

        options, args = parser.parse_known_args(args)

        if options.jobs:
            jobs = None
            try:
                jobs = int(options.jobs)
            except ValueError:
                jobs = -1

            if jobs < 1:
                parser.error(f"Invalid: --jobs='{options.jobs}'")

            options.jobs = jobs

        else:
            options.jobs = None

        if options.load_average:
            try:
                load_average = float(options.load_average)
            except ValueError:
                load_average = 0.0

            if load_average <= 0.0:
                parser.error(f"Invalid: --load-average='{options.load_average}'")

            options.load_average = load_average

        else:
            options.load_average = None

        options.config_root = options.portage_configroot
        if options.config_root is not None and not os.path.isdir(options.config_root):
            parser.error(f"Not a directory: --config-root='{options.config_root}'")

        if options.cache_dir is not None:
            if not os.path.isdir(options.cache_dir):
                parser.error(f"Not a directory: --cache-dir='{options.cache_dir}'")
            if not os.access(options.cache_dir, os.W_OK):
                parser.error(f"Write access denied: --cache-dir='{options.cache_dir}'")

        for atom in args:
            try:
                atom = portage.dep.Atom(atom)
            except portage.exception.InvalidAtom:
                parser.error(f"Invalid atom: {atom}")

            if not isjustname(atom):
                parser.error(f"Atom is too specific: {atom}")

        if options.update_use_local_desc:
            try:
                ElementTree
                ExpatError
            except NameError:
                parser.error("--update-use-local-desc requires python with USE=xml!")

        if options.uld_output == "-" and options.preserve_comments:
            parser.error(
                "--preserve-comments can not be used when outputting to stdout"
            )

        return parser, options, args

    class GenCache:
        def __init__(
            self,
            portdb,
            cp_iter=None,
            max_jobs=None,
            max_load=None,
            rsync=False,
            external_cache_only=False,
        ):
            # The caller must set portdb.porttrees in order to constrain
            # findname, cp_list, and cpv_list to the desired tree.
            tree = portdb.porttrees[0]
            self._portdb = portdb
            self._eclass_db = portdb.repositories.get_repo_for_location(tree).eclass_db
            self._auxdbkeys = portdb._known_keys
            # We can globally cleanse stale cache only if we
            # iterate over every single cp.
            self._global_cleanse = cp_iter is None
            if cp_iter is not None:
                self._cp_set = set(cp_iter)
                cp_iter = iter(self._cp_set)
                self._cp_missing = self._cp_set.copy()
            else:
                self._cp_set = None
                self._cp_missing = set()
            write_auxdb = (
                external_cache_only or "metadata-transfer" in portdb.settings.features
            )
            self._regen = MetadataRegen(
                portdb,
                cp_iter=cp_iter,
                consumer=self._metadata_callback,
                max_jobs=max_jobs,
                max_load=max_load,
                write_auxdb=write_auxdb,
                main=True,
            )
            self.returncode = os.EX_OK
            conf = portdb.repositories.get_repo_for_location(tree)
            if external_cache_only:
                self._trg_caches = ()
            else:
                self._trg_caches = tuple(
                    conf.iter_pregenerated_caches(
                        self._auxdbkeys, force=True, readonly=False
                    )
                )
                if not self._trg_caches:
                    raise Exception(
                        f"cache formats '{' '.join(conf.cache_formats)}' aren't supported"
                    )

            if rsync:
                for trg_cache in self._trg_caches:
                    if hasattr(trg_cache, "raise_stat_collision"):
                        trg_cache.raise_stat_collision = True
                        # Make _metadata_callback write this cache first, in case
                        # it raises a StatCollision and triggers mtime
                        # modification.
                        self._trg_caches = tuple(
                            [trg_cache]
                            + [x for x in self._trg_caches if x is not trg_cache]
                        )

            self._existing_nodes = set()

        def _metadata_callback(
            self, cpv, repo_path, metadata, ebuild_hash, eapi_supported
        ):
            self._existing_nodes.add(cpv)
            self._cp_missing.discard(cpv_getkey(cpv))

            # Since we're supposed to be able to efficiently obtain the
            # EAPI from _parse_eapi_ebuild_head, we don't write cache
            # entries for unsupported EAPIs.
            if metadata is not None and eapi_supported:
                for trg_cache in self._trg_caches:
                    self._write_cache(trg_cache, cpv, repo_path, metadata, ebuild_hash)

        def _write_cache(self, trg_cache, cpv, repo_path, metadata, ebuild_hash):
            if not hasattr(trg_cache, "raise_stat_collision"):
                # This cache does not avoid redundant writes automatically,
                # so check for an identical existing entry before writing.
                # This prevents unnecessary disk writes and can also prevent
                # unnecessary rsync transfers.
                try:
                    dest = trg_cache[cpv]
                except (KeyError, CacheError):
                    pass
                else:
                    if trg_cache.validate_entry(dest, ebuild_hash, self._eclass_db):
                        identical = True
                        for k in self._auxdbkeys:
                            if dest.get(k, "") != metadata.get(k, ""):
                                identical = False
                                break
                        if identical:
                            return

            try:
                chf = trg_cache.validation_chf
                metadata[f"_{chf}_"] = getattr(ebuild_hash, chf)
                try:
                    trg_cache[cpv] = metadata
                except StatCollision as sc:
                    # If the content of a cache entry changes and neither the
                    # file mtime nor size changes, it will prevent rsync from
                    # detecting changes. Cache backends may raise this
                    # exception from _setitem() if they detect this type of stat
                    # collision. These exceptions are handled by bumping the
                    # mtime on the ebuild (and the corresponding cache entry).
                    # See bug #139134. It is convenient to include checks for
                    # redundant writes along with the internal StatCollision
                    # detection code, so for caches with the
                    # raise_stat_collision attribute, we do not need to
                    # explicitly check for redundant writes like we do for the
                    # other cache types above.
                    max_mtime = sc.mtime
                    for _ec, ec_hash in metadata["_eclasses_"].items():
                        if max_mtime < ec_hash.mtime:
                            max_mtime = ec_hash.mtime
                    if max_mtime == sc.mtime:
                        max_mtime += 1
                    max_mtime = int(max_mtime)
                    try:
                        os.utime(ebuild_hash.location, (max_mtime, max_mtime))
                    except OSError as e:
                        self.returncode |= 1
                        writemsg_level(
                            f"{cpv} writing target: {e}\n",
                            level=logging.ERROR,
                            noiselevel=-1,
                        )
                    else:
                        ebuild_hash.mtime = max_mtime
                        metadata["_mtime_"] = max_mtime
                        trg_cache[cpv] = metadata
                        self._portdb.auxdb[repo_path][cpv] = metadata

            except CacheError as ce:
                self.returncode |= 1
                writemsg_level(
                    f"{cpv} writing target: {ce}\n",
                    level=logging.ERROR,
                    noiselevel=-1,
                )

        def run(self):
            signum = run_main_scheduler(self._regen)
            if signum is not None:
                sys.exit(128 + signum)

            self.returncode |= self._regen.returncode

            for trg_cache in self._trg_caches:
                self._cleanse_cache(trg_cache)

        def _cleanse_cache(self, trg_cache):
            cp_missing = self._cp_missing
            dead_nodes = set()
            if self._global_cleanse:
                try:
                    for cpv in trg_cache:
                        cp = cpv_getkey(cpv)
                        if cp is None:
                            self.returncode |= 1
                            writemsg_level(
                                f"Unable to parse cp for '{cpv}'\n",
                                level=logging.ERROR,
                                noiselevel=-1,
                            )
                        else:
                            dead_nodes.add(cpv)
                except CacheError as ce:
                    self.returncode |= 1
                    writemsg_level(
                        "Error listing cache entries for "
                        + f"'{trg_cache.location}': {ce}, continuing...\n",
                        level=logging.ERROR,
                        noiselevel=-1,
                    )

            else:
                cp_set = self._cp_set
                try:
                    for cpv in trg_cache:
                        cp = cpv_getkey(cpv)
                        if cp is None:
                            self.returncode |= 1
                            writemsg_level(
                                f"Unable to parse cp for '{cpv}'\n",
                                level=logging.ERROR,
                                noiselevel=-1,
                            )
                        else:
                            cp_missing.discard(cp)
                            if cp in cp_set:
                                dead_nodes.add(cpv)
                except CacheError as ce:
                    self.returncode |= 1
                    writemsg_level(
                        "Error listing cache entries for "
                        + f"'{trg_cache.location}': {ce}, continuing...\n",
                        level=logging.ERROR,
                        noiselevel=-1,
                    )

            if cp_missing:
                self.returncode |= 1
                for cp in sorted(cp_missing):
                    writemsg_level(
                        f"No ebuilds or cache entries found for '{cp}'\n",
                        level=logging.ERROR,
                        noiselevel=-1,
                    )

            if dead_nodes:
                dead_nodes.difference_update(self._existing_nodes)
                for k in dead_nodes:
                    try:
                        del trg_cache[k]
                    except KeyError:
                        pass
                    except CacheError as ce:
                        self.returncode |= 1
                        writemsg_level(
                            f"{k} deleting stale cache: {ce}\n",
                            level=logging.ERROR,
                            noiselevel=-1,
                        )

            if not trg_cache.autocommits:
                try:
                    trg_cache.commit()
                except CacheError as ce:
                    self.returncode |= 1
                    writemsg_level(
                        f"committing target: {ce}\n",
                        level=logging.ERROR,
                        noiselevel=-1,
                    )

            if hasattr(trg_cache, "_prune_empty_dirs"):
                trg_cache._prune_empty_dirs()

    class GenPkgDescIndex:
        def __init__(self, repo_config, portdb, output_file, verbose=False):
            self.returncode = os.EX_OK
            self._repo_config = repo_config
            self._portdb = portdb
            self._output_file = output_file
            self._verbose = verbose

        def run(self):
            display_updates = self._verbose > 0
            old = {}
            new = {}
            if display_updates:
                try:
                    with open(
                        self._output_file, encoding=_encodings["repo.content"]
                    ) as f:
                        for line in f:
                            pkg_desc = pkg_desc_index_line_read(line)
                            old[pkg_desc.cp] = pkg_desc
                except FileNotFoundError:
                    pass

            portage.util.ensure_dirs(os.path.dirname(self._output_file))
            f = portage.util.atomic_ofstream(
                self._output_file, encoding=_encodings["repo.content"]
            )

            portdb = self._portdb
            for cp in portdb.cp_all():
                pkgs = portdb.cp_list(cp)
                if not pkgs:
                    continue
                (desc,) = portdb.aux_get(pkgs[-1], ["DESCRIPTION"])

                line = pkg_desc_index_line_format(cp, pkgs, desc)
                f.write(line)
                if display_updates:
                    new[cp] = pkg_desc_index_line_read(line)

            f.close()

            if display_updates:
                out = EOutput()
                out.einfo("Searching for changes")
                print("")
                items = sorted(new.values(), key=lambda pkg_desc: pkg_desc.cp)
                haspkgs = False
                for pkg_desc in items:
                    masked = False
                    version = self._portdb.xmatch(
                        "bestmatch-visible",
                        Atom(f"{pkg_desc.cp}{_repo_separator}{self._repo_config.name}"),
                    )
                    if not version:
                        version = pkg_desc.cpv_list[-1]
                        masked = True
                    old_versions = old.get(pkg_desc.cp)
                    if old_versions is None or version not in old_versions.cpv_list:
                        prefix0 = " "
                        prefix1 = " "

                        if old_versions is None:
                            color = functools.partial(colorize, "darkgreen")
                            prefix1 = "N"
                        else:
                            color = functools.partial(colorize, "turquoise")
                            prefix1 = "U"

                        if masked:
                            prefix0 = "M"

                        print(
                            f" [{colorize('red', prefix0)}{color(prefix1)}] {colorize('bold', pkg_desc.cp)} ({color(version[len(pkg_desc.cp) + 1 :])}):  {pkg_desc.desc}"
                        )
                        haspkgs = True

                if not haspkgs:
                    out.einfo("No updates found")

    class GenUseLocalDesc:
        def __init__(self, portdb, output=None, preserve_comments=False):
            self.returncode = os.EX_OK
            self._portdb = portdb
            self._output = output
            self._preserve_comments = preserve_comments

        def run(self):
            repo_path = self._portdb.porttrees[0]
            ops = {"<": 0, "<=": 1, "=": 2, ">=": 3, ">": 4}
            prev_mtime = None
            prev_md5 = None

            if self._output is None or self._output != "-":
                if self._output is None:
                    prof_path = os.path.join(repo_path, "profiles")
                    desc_path = os.path.join(prof_path, "use.local.desc")
                    try:
                        os.mkdir(prof_path)
                    except OSError:
                        pass
                else:
                    desc_path = self._output

                try:
                    prev_md5 = portage.checksum.perform_md5(desc_path)
                    prev_mtime = os.stat(desc_path)[stat.ST_MTIME]
                except (portage.exception.FileNotFound, OSError):
                    pass

                try:
                    if self._preserve_comments:
                        # Probe in binary mode, in order to avoid
                        # potential character encoding issues.
                        output = open(
                            _unicode_encode(
                                desc_path, encoding=_encodings["fs"], errors="strict"
                            ),
                            "r+b",
                        )
                    else:
                        output = open(
                            _unicode_encode(
                                desc_path, encoding=_encodings["fs"], errors="strict"
                            ),
                            mode="w",
                            encoding=_encodings["repo.content"],
                            errors="backslashreplace",
                        )
                except OSError as e:
                    if not self._preserve_comments or os.path.isfile(desc_path):
                        writemsg_level(
                            f"ERROR: failed to open output file {desc_path}: {e}\n",
                            level=logging.ERROR,
                            noiselevel=-1,
                        )
                        self.returncode |= 2
                        return

                    # Open in r+b mode failed because the file doesn't
                    # exist yet. We can probably recover if we disable
                    # preserve_comments mode now.
                    writemsg_level(
                        "WARNING: --preserve-comments enabled, but "
                        + f"output file not found: {desc_path}\n",
                        level=logging.WARNING,
                        noiselevel=-1,
                    )
                    self._preserve_comments = False
                    try:
                        output = open(
                            _unicode_encode(
                                desc_path, encoding=_encodings["fs"], errors="strict"
                            ),
                            mode="w",
                            encoding=_encodings["repo.content"],
                            errors="backslashreplace",
                        )
                    except OSError as e:
                        writemsg_level(
                            f"ERROR: failed to open output file {desc_path}: {e}\n",
                            level=logging.ERROR,
                            noiselevel=-1,
                        )
                        self.returncode |= 2
                        return
            else:
                output = sys.stdout

            if self._preserve_comments:
                while True:
                    pos = output.tell()
                    if not output.readline().startswith(b"#"):
                        break
                output.seek(pos)
                output.truncate()
                output.close()

                # Finished probing comments in binary mode, now append
                # in text mode.
                output = open(
                    _unicode_encode(
                        desc_path, encoding=_encodings["fs"], errors="strict"
                    ),
                    mode="a",
                    encoding=_encodings["repo.content"],
                    errors="backslashreplace",
                )
                output.write("\n")
            else:
                output.write(
                    textwrap.dedent(
                        """\
                    # This file is deprecated as per GLEP 56 in favor of metadata.xml. Please add
                    # your descriptions to your package's metadata.xml ONLY.
                    # * generated automatically using egencache *

                    """
                    )
                )

            # The cmp function no longer exists in python3, so we'll
            # implement our own here under a slightly different name
            # since we don't want any confusion given that we never
            # want to rely on the builtin cmp function.
            def cmp_func(a, b):
                if a is None or b is None:
                    # None can't be compared with other types in python3.
                    if a is None and b is None:
                        return 0
                    elif a is None:
                        return -1
                    else:
                        return 1
                return (a > b) - (a < b)

            class _MetadataTreeBuilder(ElementTree.TreeBuilder):
                """
                Implements doctype() as required to avoid deprecation warnings
                since Python >=2.7
                """

                def doctype(self, name, pubid, system):
                    pass

            for cp in self._portdb.cp_all():
                metadata_path = os.path.join(repo_path, cp, "metadata.xml")
                try:
                    metadata = ElementTree.parse(
                        _unicode_encode(
                            metadata_path, encoding=_encodings["fs"], errors="strict"
                        ),
                        parser=ElementTree.XMLParser(target=_MetadataTreeBuilder()),
                    )
                except OSError:
                    pass
                except (ExpatError, OSError) as e:
                    writemsg_level(
                        f"ERROR: failed parsing {cp}/metadata.xml: {e}\n",
                        level=logging.ERROR,
                        noiselevel=-1,
                    )
                    self.returncode |= 1
                else:
                    try:
                        usedict = parse_metadata_use(metadata)
                    except portage.exception.ParseError as e:
                        writemsg_level(
                            f"ERROR: failed parsing {cp}/metadata.xml: {e}\n",
                            level=logging.ERROR,
                            noiselevel=-1,
                        )
                        self.returncode |= 1
                    else:
                        for flag in sorted(usedict):

                            def atomcmp(atoma, atomb):
                                # None is better than an atom, that's why we reverse the args
                                if atoma is None or atomb is None:
                                    return cmp_func(atomb, atoma)
                                # Same for plain PNs (.operator is None then)
                                elif atoma.operator is None or atomb.operator is None:
                                    return cmp_func(atomb.operator, atoma.operator)
                                # Version matching
                                elif atoma.cpv != atomb.cpv:
                                    return vercmp(atoma.version, atomb.version)
                                # Versions match, let's fallback to operator matching
                                else:
                                    return cmp_func(
                                        ops.get(atoma.operator, -1),
                                        ops.get(atomb.operator, -1),
                                    )

                            def _Atom(key):
                                if key is not None:
                                    return Atom(key)
                                return None

                            resdict = usedict[flag]
                            if len(resdict) == 1:
                                resdesc = next(iter(resdict.items()))[1]
                            else:
                                try:
                                    reskeys = {_Atom(k): k for k in resdict}
                                except portage.exception.InvalidAtom as e:
                                    writemsg_level(
                                        f"ERROR: failed parsing {cp}/metadata.xml: {e}\n",
                                        level=logging.ERROR,
                                        noiselevel=-1,
                                    )
                                    self.returncode |= 1
                                    resdesc = next(iter(resdict.items()))[1]
                                else:
                                    resatoms = sorted(
                                        reskeys, key=cmp_sort_key(atomcmp)
                                    )
                                    resdesc = resdict[reskeys[resatoms[-1]]]

                            output.write(f"{cp}:{flag} - {resdesc}\n")

            output.close()
            if prev_mtime is not None and prev_md5 == portage.checksum.perform_md5(
                desc_path
            ):
                # Preserve mtime for rsync.
                mtime = prev_mtime
            else:
                # For portability, and consistency with the mtime preservation
                # code, set mtime to an exact integer value.
                mtime = int(time.time())

            os.utime(desc_path, (mtime, mtime))

    class GenChangeLogs:
        def __init__(
            self,
            portdb,
            changelog_output,
            changelog_reversed,
            max_jobs=None,
            max_load=None,
        ):
            self.returncode = os.EX_OK
            self._portdb = portdb
            self._wrapper = textwrap.TextWrapper(
                width=78, initial_indent="  ", subsequent_indent="  "
            )
            self._changelog_output = changelog_output
            self._changelog_reversed = changelog_reversed
            self._max_jobs = max_jobs
            self._max_load = max_load
            self._repo_path = self._portdb.porttrees[0]
            # --work-tree=... must be passed to Git if GIT_DIR is used
            # and GIT_DIR is not a child of the root of the checkout
            # eg:
            # GIT_DIR=${parent}/work/.git/
            # work-tree=${parent}/staging/
            # If work-tree is not passed, Git tries to use the shared
            # parent of the current directory and the ${GIT_DIR}, which can
            # be outside the root of the checkout.
            self._work_tree = f"--work-tree={self._repo_path}"

        @staticmethod
        def grab(cmd):
            p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
            return _unicode_decode(
                p.communicate()[0], encoding=_encodings["stdio"], errors="strict"
            )

        def generate_changelog(self, cp):
            os.chdir(os.path.join(self._repo_path, cp))
            # Determine whether ChangeLog is up-to-date by comparing
            # the newest commit timestamp with the ChangeLog timestamp.
            lmod = self.grab(["git", self._work_tree, "log", "--format=%ct", "-1", "."])
            if not lmod:
                # This cp has not been added to the repo.
                return

            lmod = int(lmod)

            try:
                cmod = os.stat("ChangeLog")[stat.ST_MTIME]
            except OSError:
                cmod = 0

            # Use exact comparison, since commit times are
            # not necessarily ordered.
            if cmod == lmod:
                return

            try:
                output = open(
                    self._changelog_output,
                    mode="w",
                    encoding=_encodings["repo.content"],
                    errors="backslashreplace",
                )
            except OSError as e:
                writemsg_level(
                    f"ERROR: failed to open ChangeLog for {cp}: {e}\n",
                    level=logging.ERROR,
                    noiselevel=-1,
                )
                self.returncode |= 2
                return

            output.write(
                textwrap.dedent(
                    f"""\
                # ChangeLog for {cp}
                # Copyright 1999-{time.strftime("%Y")} Gentoo Foundation; Distributed under the GPL v2
                # (auto-generated from git log)

                """
                )
            )

            # now grab all the commits
            revlist_cmd = ["git", self._work_tree, "rev-list"]
            if self._changelog_reversed:
                revlist_cmd.append("--reverse")
            revlist_cmd.extend(["HEAD", "--", "."])
            commits = self.grab(revlist_cmd).split()

            for c in commits:
                # Explaining the arguments:
                # --name-status to get a list of added/removed files
                # --no-renames to avoid getting more complex records on the list
                # --format to get the timestamp, author and commit description
                # --root to make it work fine even with the initial commit
                # --relative=${cp} to get paths relative to ebuilddir
                # -r (recursive) to get per-file changes
                # then the commit-id and path.

                cinfo = (
                    self.grab(
                        [
                            "git",
                            self._work_tree,
                            "diff-tree",
                            "--name-status",
                            "--no-renames",
                            "--format=%ct %cN <%cE>%n%B",
                            "--root",
                            f"--relative={cp}",
                            "-r",
                            c,
                            "--",
                            ".",
                        ]
                    )
                    .rstrip("\n")
                    .split("\n")
                )

                # Expected output:
                # timestamp Author Name <author@email>
                # commit message l1
                # ...
                # commit message ln
                #
                # status1	filename1
                # ...
                # statusn	filenamen

                changed = []
                for n, l in enumerate(reversed(cinfo)):
                    if not l:
                        body = cinfo[1 : -n - 1]
                        break
                    else:
                        f = l.split()
                        if f[1] == "Manifest":
                            pass  # XXX: remanifest commits?
                        elif f[1].startswith("ChangeLog"):
                            pass
                        elif f[0].startswith("A"):
                            changed.append(ChangeLogTypeSort("+", f[1]))
                        elif f[0].startswith("D"):
                            changed.append(ChangeLogTypeSort("-", f[1]))
                        elif f[0].startswith("M"):
                            changed.append(ChangeLogTypeSort("", f[1]))
                        else:
                            writemsg_level(
                                f"ERROR: unexpected git file status for {cp}: {f}\n",
                                level=logging.ERROR,
                                noiselevel=-1,
                            )
                            self.returncode |= 1

                if not changed:
                    continue

                (ts, author) = cinfo[0].split(" ", 1)
                date = time.strftime("%d %b %Y", time.gmtime(float(ts)))

                changed = [str(x) for x in sorted(changed)]

                wroteheader = False
                # Reverse the sort order for headers.
                for c in reversed(changed):
                    if c.startswith("+") and c.endswith(".ebuild"):
                        output.write(f"*{c[1:-7]} ({date})\n")
                        wroteheader = True
                if wroteheader:
                    output.write("\n")

                # strip '<cp>: ', '[<cp>] ', and similar
                body[0] = re.sub(r"^\W*" + re.escape(cp) + r"\W+", "", body[0])
                # strip trailing newline
                if not body[-1]:
                    body = body[:-1]
                # strip git-svn id
                if body[-1].startswith("git-svn-id:") and not body[-2]:
                    body = body[:-2]
                # strip the repoman version/manifest note
                if (
                    body[-1] == " (Signed Manifest commit)"
                    or body[-1] == " (Unsigned Manifest commit)"
                ):
                    body = body[:-1]
                if body[-1].startswith("(Portage version:") and body[-1].endswith(")"):
                    body = body[:-1]
                    if not body[-1]:
                        body = body[:-1]

                # don't break filenames on hyphens
                self._wrapper.break_on_hyphens = False
                output.write(
                    self._wrapper.fill(f"{date}; {author} {', '.join(changed)}:")
                )
                # but feel free to break commit messages there
                self._wrapper.break_on_hyphens = True
                # temp var needed because fstrings can not have backslashes in
                # the expression part...
                temp_joined = "\n".join(self._wrapper.fill(x) for x in body)
                output.write(f"\n{temp_joined}\n\n")

            output.close()
            os.utime(self._changelog_output, (lmod, lmod))

        def _task_iter(self):
            if not os.path.isdir(
                os.environ.get("GIT_DIR", os.path.join(self._repo_path, ".git"))
            ):
                writemsg_level(
                    "ERROR: --update-changelogs supported only in git repos\n",
                    level=logging.ERROR,
                    noiselevel=-1,
                )
                self.returncode = 127
                return

            for cp in self._portdb.cp_all():
                yield AsyncFunction(target=self.generate_changelog, args=[cp])

        def run(self):
            return run_main_scheduler(
                TaskScheduler(
                    self._task_iter(),
                    event_loop=global_event_loop(),
                    max_jobs=self._max_jobs,
                    max_load=self._max_load,
                )
            )

    def egencache_main(args):
        # The calling environment is ignored, so the program is
        # completely controlled by commandline arguments.
        env = {}

        # Pass through PATH to allow testing with an empty profile.env.
        if "PATH" in os.environ:
            env["PATH"] = os.environ["PATH"]

        if not sys.stdout.isatty() or no_color(os.environ):
            portage.output.nocolor()
            env["NO_COLOR"] = "true"

        parser, options, atoms = parse_args(args)

        config_root = options.config_root

        if options.repositories_configuration is not None:
            env["PORTAGE_REPOSITORIES"] = options.repositories_configuration

        if options.cache_dir is not None:
            env["PORTAGE_DEPCACHEDIR"] = options.cache_dir

        settings = portage.config(config_root=config_root, local_config=False, env=env)

        default_opts = None
        if not options.ignore_default_opts:
            default_opts = portage.util.shlex_split(
                settings.get("EGENCACHE_DEFAULT_OPTS", "")
            )

        if default_opts:
            parser, options, args = parse_args(default_opts + args)

            if options.cache_dir is not None:
                env["PORTAGE_DEPCACHEDIR"] = options.cache_dir

            settings = portage.config(
                config_root=config_root, local_config=False, env=env
            )

        if not (
            options.update
            or options.update_use_local_desc
            or options.update_changelogs
            or options.update_manifests
            or options.update_pkg_desc_index
        ):
            parser.error("No action specified")
            return 1

        if options.repo is None:
            if len(settings.repositories.prepos) == 2:
                for repo in settings.repositories:
                    if repo.name != "DEFAULT":
                        options.repo = repo.name
                        break

            if options.repo is None:
                parser.error("--repo option is required")

        repo_path = settings.repositories.treemap.get(options.repo)
        if repo_path is None:
            parser.error(f"Unable to locate repository named '{options.repo}'")
            return 1

        repo_config = settings.repositories.get_repo_for_location(repo_path)

        if options.strict_manifests is not None:
            if options.strict_manifests == "y":
                settings.features.add("strict")
            else:
                settings.features.discard("strict")

        if options.update and "metadata-transfer" not in settings.features:
            # Forcibly enable metadata-transfer if portdbapi has a pregenerated
            # cache that does not support eclass validation.
            cache = repo_config.get_pregenerated_cache(
                portage.dbapi.dbapi._known_keys, readonly=True
            )
            if cache is not None and not cache.complete_eclass_entries:
                settings.features.add("metadata-transfer")
            cache = None

        settings.lock()

        portdb = portage.portdbapi(mysettings=settings)

        # Limit ebuilds to the specified repo.
        portdb.porttrees = [repo_path]

        if options.update:
            if options.cache_dir is not None:
                # already validated earlier
                pass
            else:
                # We check write access after the portdbapi constructor
                # has had an opportunity to create it. This ensures that
                # we don't use the cache in the "volatile" mode which is
                # undesirable for egencache.
                if not os.access(settings["PORTAGE_DEPCACHEDIR"], os.W_OK):
                    writemsg_level(
                        "ecachegen: error: "
                        + f"write access denied: {settings['PORTAGE_DEPCACHEDIR']}\n",
                        level=logging.ERROR,
                        noiselevel=-1,
                    )
                    return 1

        if options.sign_manifests is not None:
            repo_config.sign_manifest = options.sign_manifests == "y"

        if options.thin_manifests is not None:
            repo_config.thin_manifest = options.thin_manifests == "y"

        gpg_cmd = None
        gpg_vars = None
        force_sign_key = None

        if options.update_manifests:
            if repo_config.sign_manifest:
                sign_problem = False
                gpg_dir = None
                gpg_cmd = settings.get("PORTAGE_GPG_SIGNING_COMMAND")
                if gpg_cmd is None:
                    writemsg_level(
                        "egencache: error: "
                        "PORTAGE_GPG_SIGNING_COMMAND is unset! "
                        "Is make.globals missing?\n",
                        level=logging.ERROR,
                        noiselevel=-1,
                    )
                    sign_problem = True
                elif (
                    "${PORTAGE_GPG_KEY}" in gpg_cmd
                    and options.gpg_key is None
                    and "PORTAGE_GPG_KEY" not in settings
                ):
                    writemsg_level(
                        "egencache: error: " "PORTAGE_GPG_KEY is unset!\n",
                        level=logging.ERROR,
                        noiselevel=-1,
                    )
                    sign_problem = True
                elif "${PORTAGE_GPG_DIR}" in gpg_cmd:
                    if options.gpg_dir is not None:
                        gpg_dir = options.gpg_dir
                    elif "PORTAGE_GPG_DIR" not in settings:
                        gpg_dir = os.path.expanduser("~/.gnupg")
                    else:
                        gpg_dir = os.path.expanduser(settings["PORTAGE_GPG_DIR"])
                    if not os.access(gpg_dir, os.X_OK):
                        writemsg_level(
                            "egencache: error: Unable to access directory: "
                            f"PORTAGE_GPG_DIR='{gpg_dir}'\n",
                            level=logging.ERROR,
                            noiselevel=-1,
                        )
                        sign_problem = True

                if sign_problem:
                    writemsg_level(
                        "egencache: You may disable manifest "
                        "signatures with --sign-manifests=n or by setting "
                        '"sign-manifests = false" in metadata/layout.conf\n',
                        level=logging.ERROR,
                        noiselevel=-1,
                    )
                    return 1

                gpg_vars = {}
                if gpg_dir is not None:
                    gpg_vars["PORTAGE_GPG_DIR"] = gpg_dir
                gpg_var_names = []
                if options.gpg_key is None:
                    gpg_var_names.append("PORTAGE_GPG_KEY")
                else:
                    gpg_vars["PORTAGE_GPG_KEY"] = options.gpg_key

                for k in gpg_var_names:
                    v = settings.get(k)
                    if v is not None:
                        gpg_vars[k] = v

                force_sign_key = gpg_vars.get("PORTAGE_GPG_KEY")

        ret = [os.EX_OK]

        if options.update:
            cp_iter = None
            if atoms:
                cp_iter = iter(atoms)

            gen_cache = GenCache(
                portdb,
                cp_iter=cp_iter,
                max_jobs=options.jobs,
                max_load=options.load_average,
                rsync=options.rsync,
                external_cache_only=options.external_cache_only,
            )
            gen_cache.run()
            if options.tolerant:
                ret.append(os.EX_OK)
            else:
                ret.append(gen_cache.returncode)

        if options.update_pkg_desc_index:
            if not options.external_cache_only and repo_config.writable:
                writable_location = repo_config.location
            else:
                writable_location = os.path.join(
                    portdb.depcachedir, repo_config.location.lstrip(os.sep)
                )
                if not options.external_cache_only:
                    msg = [
                        f"WARNING: Repository is not writable: {repo_config.location}",
                        f"         Using cache directory instead: {writable_location}",
                    ]
                    msg = "".join(line + "\n" for line in msg)
                    writemsg_level(msg, level=logging.WARNING, noiselevel=-1)

            gen_index = GenPkgDescIndex(
                repo_config,
                portdb,
                os.path.join(writable_location, "metadata", "pkg_desc_index"),
                verbose=options.verbose,
            )
            gen_index.run()
            ret.append(gen_index.returncode)

        if options.update_use_local_desc:
            gen_desc = GenUseLocalDesc(
                portdb,
                output=options.uld_output,
                preserve_comments=options.preserve_comments,
            )
            gen_desc.run()
            ret.append(gen_desc.returncode)

        if options.update_changelogs:
            gen_clogs = GenChangeLogs(
                portdb,
                changelog_output=options.changelog_output,
                changelog_reversed=options.changelog_reversed,
                max_jobs=options.jobs,
                max_load=options.load_average,
            )
            signum = gen_clogs.run()
            if signum is not None:
                sys.exit(128 + signum)
            ret.append(gen_clogs.returncode)

        if options.update_manifests:
            cp_iter = None
            if atoms:
                cp_iter = iter(atoms)

            event_loop = global_event_loop()
            scheduler = ManifestScheduler(
                portdb,
                cp_iter=cp_iter,
                gpg_cmd=gpg_cmd,
                gpg_vars=gpg_vars,
                force_sign_key=force_sign_key,
                max_jobs=options.jobs,
                max_load=options.load_average,
                event_loop=event_loop,
            )

            signum = run_main_scheduler(scheduler)
            if signum is not None:
                sys.exit(128 + signum)

            if options.tolerant:
                ret.append(os.EX_OK)
            else:
                ret.append(scheduler.returncode)

        if options.write_timestamp:
            timestamp_path = os.path.join(repo_path, "metadata", "timestamp.chk")
            try:
                portage.util.write_atomic(
                    timestamp_path,
                    time.strftime(f"{TIMESTAMP_FORMAT}\n", time.gmtime()),
                )
            except (OSError, portage.exception.PortageException):
                ret.append(os.EX_IOERR)
            else:
                ret.append(os.EX_OK)

        return max(ret)

    if __name__ == "__main__":
        portage._disable_legacy_globals()
        portage.util.noiselimit = -1
        try:
            sys.exit(egencache_main(sys.argv[1:]))
        finally:
            global_event_loop().close()

except KeyboardInterrupt as e:
    # Prevent traceback on ^C
    signum = getattr(e, "signum", signal.SIGINT)
    signal.signal(signum, signal.SIG_DFL)
    raise_signal(signum)
